Skip to content

CDAP-21261 : Lease or Locking support for Secure Store - RTR Oauth - #16201

Open
sahusanket wants to merge 3 commits into
developfrom
CDAP-21261_RTR_lease_support
Open

CDAP-21261 : Lease or Locking support for Secure Store - RTR Oauth#16201
sahusanket wants to merge 3 commits into
developfrom
CDAP-21261_RTR_lease_support

Conversation

@sahusanket

@sahusanket sahusanket commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Title:
feat: Add distributed lease support for GCP Secret Manager (CDAP-21261)

Description:
This PR introduces distributed locking capabilities (acquireLease, releaseLease, and isLeaseSupported) to the SecretManager SPI and implements them for GcpSecretManager using Google Cloud Secret Manager annotations and ETags for strict concurrency control.

Why this is required for Refresh Token Rotation (RTR):
Modern OAuth providers (like Salesforce) enforce strict one-time-use constraints on refresh tokens. In a distributed CDAP environment, if multiple pods detect an expired access token and attempt to refresh it simultaneously, it causes a race condition that permanently invalidates the token chain, locking the instance out. This lease mechanism provides a distributed lock, ensuring that only one pod can perform the token rotation at any given time, while other pods safely wait for the new token to be propagated.

Key Changes:

  • Added acquireLease / releaseLease to the SPI.
  • Used GCP Secret Manager annotations (state, lock_timestamp, lock_holder) as a distributed mutex.
  • Implemented ETag matching to guarantee atomicity during lease state transitions.
  • Added robust idempotency, contention, and expiration logic to prevent deadlocks during transient network failures.

Manual Verification Performed:
All core scenarios were manually verified against a live CDF cluster [WITH GCP-SECRETMANAGER] utilizing the internal REST endpoints for secure keys:

Scenario Action Expected / Verified Result
1. Support Check Check if leases are supported:
GET /v3/namespaces/system/securekeys/lease/supported
Returns HTTP 200 OK with true.
2. Happy Path (Acquire) Simulate a pod acquiring a 60s lease:
POST /v3/namespaces/system/securekeys/test-key/lease?timeoutMs=60000&lockHolder=pod-1
Returns HTTP 200 OK with {"acquired":true, "lockTimestamp":"...", "lockHolder":"pod-1"}.
3. Lock Contention Immediately after Test 2, a second pod tries to acquire:
POST /v3/namespaces/system/securekeys/test-key/lease?timeoutMs=60000&lockHolder=pod-2
Returns HTTP 200 OK with {"acquired":false}. (Lock successfully rejected).
4. Happy Path (Release) Original pod releases the lock:
DELETE /v3/namespaces/system/securekeys/test-key/lease (with body from Test 2)
Returns HTTP 200 OK. Lock is cleared.
5. Expiration Takeover pod-1 acquires a 10s lease. Wait 12s, then pod-2 attempts to acquire. Returns HTTP 200 OK with {"acquired":true...}. pod-2 successfully evicts the expired lock.
6. Mismatch Rejection pod-1 acquires a lock. pod-2 maliciously tries to release it. Returns HTTP 400 Bad Request / IOException (Cannot release lease: held by different owner).
7. Idempotency pod-1 acquires a lock. pod-1 re-sends the acquire request (simulated retry). Returns HTTP 200 OK with {"acquired":true...} and a renewed lock timestamp.

@sahusanket sahusanket self-assigned this Aug 14, 2026
@sahusanket sahusanket added the build Triggers github actions build label Aug 14, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces distributed lease locking capabilities to the CDAP Secure Store, adding API and SPI models (SecureStoreLease and SecretLease) and implementing lease operations across the secure store service, handler, and GCP Secret Manager extension. Feedback on the changes highlights several critical and high-severity issues: a missing v3/namespaces/ path prefix in RemoteSecureStore that will cause 404 errors; robustness and idempotency issues in GcpSecretManager's lease acquisition and release logic (such as ignoring ETag mismatch failures and failing on retries); a missing validation check in SecureStoreHandler for empty lease bodies; and the potential leakage of internal locking metadata into user-visible properties in WrappedSecret.

@sahusanket
sahusanket force-pushed the CDAP-21261_RTR_lease_support branch 2 times, most recently from 1dbf5c8 to 67e7cf9 Compare August 14, 2026 09:57
* the License.
*/

package io.cdap.cdap.api.security.store.lease;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be moved to io.cdap.cdap.api.security.store package, if you don't expect more classes to be added to lease subpackage.

try {
return Retries.callWithRetries(
() -> secureStoreManager.acquireLease(namespace, name, timeoutMs, lockHolder), retryStrategy);
} catch (IOException | RuntimeException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for catch block. Let the exception simply propagate, similar to other methods in this class.

public void releaseLease(String namespace, String name, SecureStoreLease lease) throws IOException {
try {
Retries.runWithRetries(() -> secureStoreManager.releaseLease(namespace, name, lease), retryStrategy);
} catch (IOException | RuntimeException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for catch block. Let the exception simply propagate, similar to other methods in this class.

* @return {@code true} if update succeeded, {@code false} if ETag mismatch occurred (FAILED_PRECONDITION)
* @throws ApiException if another Google API failure occurs.
*/
public boolean updateSecretWithEtag(String namespace,

@vsethi09 vsethi09 Aug 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: please rename to updateSecretAnnotations, since it is only updating the annotations, not the secret itself.

return true;
} catch (ApiException e) {
if (e.getStatusCode().getCode() == StatusCode.Code.FAILED_PRECONDITION) {
LOG.debug("Optimistic lock failure (ETag mismatch) for secret {} in namespace {}", name, namespace);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From implementation perspective, this method is only trying to update annotations. Client may be using it to acquire lock, etc which should not be mentioned in the debug log.

secretBuilder.build(),
FieldMask.newBuilder().addPaths("annotations").build());
return true;
} catch (ApiException e) {

@vsethi09 vsethi09 Aug 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let this generic client throw the exception. Let the caller handle the exception, similar to other methods.

private final SecretMetadata secretMetadata;
@Nullable
private final String etag;
private final Map<String, String> annotations;

@vsethi09 vsethi09 Aug 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecretMetadata has Map<String, String> properties, can it be reused? Does it have a different purpose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In GCP SM we have

SecretMetadata has Map<String, String> properties,

This is intended to be used by end user for any meta data they want to store.

This is stored in annotations under the name "cdap_prop"

Rest all annotations are handled by platform. Hence we cannot reuse this paricular variable.

Comment on lines +145 to +147
if (!secret.getEtag().isEmpty()) {
props.put("etag", secret.getEtag());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed?

eq(NAMESPACE), eq("salesforce"), ArgumentMatchers.any(), ArgumentMatchers.any()))
.thenReturn(true);

io.cdap.cdap.securestore.spi.SecretLease lease =

@vsethi09 vsethi09 Aug 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be imported?

Fix t/io

secretManager.acquireLease(NAMESPACE, "salesforce", 30000L, "test-lock-holder");
assertTrue(lease.isAcquired());

java.lang.reflect.Field field = WrappedSecret.class.getDeclaredField("annotations");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be imported?

Fix t/io

WrappedSecret wrappedSecret = WrappedSecret.fromMetadata(NAMESPACE, metadata);
when(client.getSecret(eq(NAMESPACE), eq("salesforce"))).thenReturn(wrappedSecret);
when(client.updateSecretWithEtag(eq(NAMESPACE), eq("salesforce"),
org.mockito.ArgumentMatchers.anyMap(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be imported?

Fix t/io

io.cdap.cdap.securestore.spi.SecretLease lease =
io.cdap.cdap.securestore.spi.SecretLease.acquired("test-timestamp", "test-lock-holder");

// Should not throw, should return early

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

supernit: Remove obvious comments.

HttpResponse response = remoteClient.execute(request, Idempotency.IDEMPOTENT);
return Boolean.parseBoolean(response.getResponseBodyAsString());
} catch (Exception e) {
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't ignore exception, let caller handle it?

Caller might want to retry network failures, which are getting silently ignored now.

handleResponse(response, namespace, name,
String.format("Error occurred while acquiring lease for key %s:%s", namespace, name));
return GSON.fromJson(response.getResponseBodyAsString(), SecureStoreLease.class);
} catch (IOException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for catch block.

HttpResponse response = remoteClient.execute(request, Idempotency.NONE);
handleResponse(response, namespace, name,
String.format("Error occurred while releasing lease for key %s:%s", namespace, name));
} catch (IOException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for catch block.


@Override
public boolean isLeaseSupported() {
return this.secretManager != null && this.secretManager.isLeaseSupported();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (secretManager == null) {
throw new RuntimeException("Secret manager is either not initialized or not loaded. ");
}

public SecureStoreLease acquireLease(String namespace, String name, long timeoutMs,
String lockHolder) throws IOException {
try {
SecretLease spiLease = this.secretManager.acquireLease(namespace, name, timeoutMs, lockHolder);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use better variable name for spiLease?


@Override
public SecretLease acquireLease(String namespace, String key, long timeoutMs, String lockHolder) throws IOException {
long now = System.currentTimeMillis();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Define closer to first usage

private CloudSecretManagerClient client;

private static final String ANNOTATION_STATE = "state";
private static final String ANNOTATION_LOCK_TIMESTAMP = "lock_timestamp";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lock_timestamp doesn't clearly mention what is the timestamp for? Lease acquired, released, etc. Use better name to reflect the purpose.

}

@Override
public SecretLease acquireLease(String namespace, String key, long timeoutMs, String lockHolder) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please simplify the code in this file and also improve error handling code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduced the reductant error handling and original logic by few lines.

}

@Override
public void releaseLease(String namespace, String key, SecretLease lease) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please simplify the code in this file and also improve error handling code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduced the reductant error handling and original logic by few lines.

Comment on lines +235 to +244
} catch (IOException | RuntimeException e) {
LOG.error("Exception occurred while acquiring lease for namespace '{}' name '{}': {}",
namespace, name, e.getMessage(), e);
throw e;
} catch (Exception e) {
LOG.error("Unexpected exception occurred while acquiring lease for namespace '{}' name '{}': {}",
namespace, name, e.getMessage(), e);
throw new IOException(e);
}
return SecureStoreLease.failed();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to catch exception and log it. Let exception propagate and let client handle it.

Comment on lines +134 to +139
@Path("/lease/supported")
@GET
public void isLeaseSupported(HttpRequest httpRequest, HttpResponder httpResponder,
@PathParam("namespace-id") String namespace) throws Exception {
httpResponder.sendString(HttpResponseStatus.OK, String.valueOf(secureStoreManager.isLeaseSupported()));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use better REST API design for this use case?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1st step of any Oauth process is to create a CDAP OAUTH PROVIDER

And contains Oauth RefreshType = Standard or RTR or something new in future.


IN this step We need to check If RefreshType = RTR , then does the backend secure store support Lease ?

and having lease support for RTR is a definite requirement.

That is why i introduced this api.


The 2nd step :

  • Get the auth url from CDF
  • NO involvement of RTR or leasing

The 3rd step :

  • The end user will take the url from above and authenticate it with the actual server like salesforce and get a One Time Code.
  • NO involvement of RTR or leasing

4th step :

  • User will call CDF service ONCE to get the access and refresh token and it will stored.
  • NO involvement of RTR or leasing

5th Step :

  • Finally when Pipeline is step and run, then it needs REFRESHING.

If we depend on acquireLease 's unsupported exception , then it's too late and it would be a bad experience for users.


Please let me know if you feel there is a better way to reject RTR at the earlier stage.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is OAuthHandler doesn't know the underlying implementation for secure store and its supported capabilities.

Alternatives:

  1. Check the secure store provider from CConf in OAuthStore. But OAuth should have store specific business logic / checks.
  2. Expose an API to read the supported capabilities of secure store. Which you are doing in this file.

Problem with current REST API is that it is not extensible. Each time a new capability needs to be added, SecureStoreHandler shouldn't expose functions.

Instead, implement a generic API like /metadata or something better to fetch the secure store metadata with the capabilities. The RemoteSecureStore should query the metadata when needed and cache it (lazy loading).

@sahusanket
sahusanket force-pushed the CDAP-21261_RTR_lease_support branch from 67e7cf9 to 3c02507 Compare August 17, 2026 11:30
}

if (!lease.getLockHolder().equals(currentLockHolder)) {
throw new IOException(String.format("Cannot release lease for %s: lock held by %s.", key, currentLockHolder));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the caller wraps releaseLease in a Retries.runWithRetries loop, throwing an IOException here will cause the caller to blindly retry releasing a lock it no longer owns until max retries are exhausted.

instead we can simply return

try {
lockTimestamp = Long.parseLong(refreshSecret.getAnnotation(ANNOTATION_LEASE_ACQUIRED_TIME_MS, "0"));
} catch (NumberFormatException e) {
// ignore invalid timestamp

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a quick debug or trace log so it's not entirely invisible.

LOG.debug("Invalid lease timestamp found for secret {}, treating as expired.", key);

builder.setTtl(Duration.newBuilder().setSeconds(ttlInSeconds).build());
}

if (etag != null && !etag.isEmpty()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Strings.isNotEmpty()

if (etag != null && !etag.isEmpty()) {
builder.setEtag(etag);
}
if (additionalAnnotations != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cannot be null. See line 64 where it is initialized.

Comment on lines +214 to +216



Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove extra lines.

*/
default SecureStoreLease acquireLease(String namespace, String name, long timeoutMs,
String lockHolder) throws Exception {
throw new UnsupportedOperationException("Distributed leases are not supported by this SecureStore implementation.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: No need to mention Distributed that is implementation detail.

* @throws Exception If lock release fails due to underlying storage errors
*/
default void releaseLease(String namespace, String name, SecureStoreLease lease) throws Exception {
throw new UnsupportedOperationException("Distributed leases are not supported by this SecureStore implementation.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: No need to mention Distributed that is implementation detail.

* @throws IOException if unable to acquire lease due to I/O error
*/
default SecretLease acquireLease(String namespace, String key, long timeoutMs, String lockHolder) throws IOException {
throw new UnsupportedOperationException("Distributed leases are not supported by this SecureStore implementation.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: No need to mention Distributed that is implementation detail.

* @throws IOException if unable to release lease due to I/O error
*/
default void releaseLease(String namespace, String key, SecretLease lease) throws IOException {
throw new UnsupportedOperationException("Distributed leases are not supported by this SecureStore implementation.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: No need to mention Distributed that is implementation detail.

@Override
public SecureStoreLease acquireLease(final String namespace, final String name,
final long timeoutMs, final String lockHolder) throws Exception {
String path = createPath(namespace, name) + "/lease?timeoutMs=" + timeoutMs + "&lockHolder=" + lockHolder;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use string builder.


@Override
public void releaseLease(String namespace, String key, SecretLease lease) throws IOException {
// simple mock: do nothing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check if secret / key exists in the map.

throw new IOException("Not found");
}
// simple mock: always return acquired for testing
return SecretLease.acquired(String.valueOf(System.currentTimeMillis()), lockHolder);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the purpose of testing you can maintain a Set of key names for which lease is acquired.

When acquiring lease check if it is already released, else acquire.

Remove from the set when released.

}

@Path("/{key-name}/lease")
@POST

@vsethi09 vsethi09 Aug 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST & DELETE REST APIs are used to create and delete resources. In this case no resource is being created. Only the state of the secret resource is being updated.

Consider using custom methods like /{key-name}:acquireLease / /{key-name}:releaseLease.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If CDAP doesn't support custom methods, then consider alternatives like:

POST /{key-name}/acquireLease

POST /{key-name}/releaseLease

httpResponder.sendStatus(HttpResponseStatus.OK);
}

private SecureStoreLease parseLeaseBody(FullHttpRequest request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generalize the parseBody(...) function instead of code duplication.

Something like:

private T parseBody(FullHttpRequest request, TypeToken<T> typeOfT) throws IOException {
    ...
    ...
}

@Override
public void releaseLease(String namespace, String name, SecureStoreLease lease) throws IOException {
if (lease != null && lease.isAcquired()) {
SecretLease spiLease =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Use better name for spiLease.

httpResponder.sendString(HttpResponseStatus.OK, String.valueOf(secureStoreManager.isLeaseSupported()));
}

@Path("/{key-name}/lease")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, should these methods use v1 internal version or v3 public version?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get/ put secret is public.

If we look from a security perspective, users can anyway tamper the secrets if the want irrespective of RTR.

We can move the apis to a internal API that would need further refactoring, but i don't see the risk.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
21.1% Coverage on New Code (required ≥ 80%)
4.4% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Triggers github actions build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants